Exercise: QR Codes

Consider the following program that uses a device's camera to scan a QR code and then navigates to the URL represented by the QR code on the device's browser:

class Camera(StrEnum):
  FRONT = "front"
  BACK = "back"

@dataclass
class QRScanner:
 camera: Camera = Camera.FRONT

 def choose_camera(self, camera: Camera) -> None:
   print(f"Choosing camera {camera.value}.")
   self.camera = camera

 def scan(self) -> str:
   print(f"Scanning QR code with {self.camera.value} camera.")
   return "https://www.arjancodes.com"

class Browser:
  def open(self, url: str) -> None:
    print(f"Opening {url} in the browser.")

  def open_from_qr_code(self) -> None:
    qr = QRScanner()
    qr.choose_camera(Camera.BACK)
    url = qr.scan()
    self.open(url)

def main() -> None:
  print("Navigating to website on device.")
  browser = Browser()
  browser.open_from_qr_code()

Apply the "Separate Creation From Use" principle to refactor this code.

Compatible Python Versions: 3.11+


Hugi Asgeirsson

Going functional for the scan seems simpler here:

from enum import StrEnum, auto

class Camera(StrEnum):
FRONT = auto()
BACK = auto()

def scan(camera: Camera) -> str:
print(f"Scanning QR code with {camera.value} camera.")
return "https://www.arjancodes.com"

class Browser:
def open(self, url: str) -> None:
print(f"Opening {url} in the browser.")

def open_from_qr_code(self) -> None:
url = scan(Camera.BACK)
self.open(url)

def main() -> None:
print("Navigating to website on device.")
browser = Browser()
browser.open_from_qr_code()

if __name__ == "__main__":
main()

REPLY
Agustin Rodriguez

Hay. My solution:
1 qr:
from dataclasses import dataclass
from enum import Enum

class Camera(str, Enum):
FRONT = "front"
BACK = "back"

@dataclass
class QRScanner:
camera: Camera = Camera.FRONT

def choose_camera(self, camera: Camera) -> None:
print(f"Choosing camera {camera.value}.")
self.camera = camera

def scan(self) -> str:
print(f"Scanning QR code with {self.camera.value} camera.")
return "https://www.arjancodes.com"

class Browser:
def open(self, url: str) -> None:
print(f"Opening {url} in the browser.")

def open_from_qr_code(self,qr) -> None:
url = qr.scan()
self.open(url)

def get_qr_reader() -> QRScanner:
qr = QRScanner()
qr.choose_camera(Camera.BACK)
return qr

def main() -> None:
print("Navigating to website on device.")

# creation
qr = get_qr_reader()
browser = Browser()

# use
browser.open_from_qr_code(qr)

if __name__ == "__main__":
main()

ex2: game
import random
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Callable, Protocol

class EnemyType(StrEnum):
KNIGHT = "knight"
ARCHER = "archer"
WIZARD = "wizard"

@dataclass
class Enemy:
enemy_type: EnemyType
health: int
attack_power: int
defense: int

SkillsFunction = Callable[[], tuple[int,int,int]]

def easy_spawn_points(seed: int =0) -> tuple[int,int,int]:
random.seed(seed)
health = random.randint(0, 20)
attack_power = random.randint(0, 20)
defense = random.randint(0, 20)
return health, attack_power, defense

def medium_spawn_points(seed: int = 0) -> tuple[int,int,int]:
random.seed(seed)
health = random.randint(21, 60)
attack_power = random.randint(21, 60)
defense = random.randint(21, 60)
return health, attack_power, defense

def hard_spawn_points(seed: int = 0) -> tuple[int,int,int]:
random.seed(seed)
health = random.randint(61,100)
attack_power = random.randint(61,100)
defense = random.randint(61,100)
return health, attack_power, defense

class EnemyFactory(Protocol):
def set_enemy_type(self) -> None:
...
def spawn(self) -> None:
...

@dataclass
class EasyEnemyFactory:
enemy_type: EnemyType = EnemyType.KNIGHT
enemy_type_options: list = field(default_factory=lambda: [EnemyType.KNIGHT, EnemyType.ARCHER])
def set_enemy_type(self) -> None:
choice = input("Enter the enemy type: knight or archer: ")
while choice not in self.enemy_type_options:
print("Choose a valid Enemy Type")
choice = input("Enter the enemy type: knight")
self.enemy_type = choice
def spawn(self, skills: SkillsFunction) -> Enemy:
health, attack_power, defense = skills()
return Enemy(self.enemy_type, health, attack_power, defense)

@dataclass
class MediumEnemyFactory:
enemy_type: EnemyType = EnemyType.KNIGHT
enemy_type_options: list = field(default_factory=lambda: [EnemyType.KNIGHT, EnemyType.ARCHER, EnemyType.WIZARD])
def set_enemy_type(self) -> None:
choice = input("Enter the enemy type: knight or archer or wizard: ")
while choice not in self.enemy_type_options:
print("Choose a valid Enemy Type")
choice = input("Enter the enemy type: knight or archer or wizard: ")
self.enemy_type = choice
def spawn(self, skills: SkillsFunction) -> Enemy:
health, attack_power, defense = skills()
return Enemy(self.enemy_type, health, attack_power, defense)

@dataclass
class HardEnemyFactory:
enemy_type: EnemyType = EnemyType.WIZARD

def set_enemy_type(self) -> None:
self.enemy_type = EnemyType.WIZARD

def spawn(self, skills: SkillsFunction) -> Enemy:
health, attack_power, defense = skills()
return Enemy(self.enemy_type, health, attack_power, defense)

SKILLS: dict[int,SkillsFunction] = {
"easy": easy_spawn_points,
"medium": medium_spawn_points,
"hard": hard_spawn_points
}

FACTORY: dict[str,EnemyFactory] = {
"easy": EasyEnemyFactory,
"medium": MediumEnemyFactory,
"hard": HardEnemyFactory
}

def create_factory() -> tuple [EnemyFactory, SkillsFunction]:
while True:
choice = input(f"Enter the difficulty level ({', '.join(FACTORY)}): ")
try:
factory = FACTORY[choice]()
factory.set_enemy_type()
skills = SKILLS[choice]
return factory, skills
except KeyError:
print(f"Unknown difficulty level: {choice}.")

def main() -> None:

enemyfactory, skills = create_factory()
enemy = enemyfactory.spawn(skills)
print(enemy)

if __name__ == "__main__":
main()

REPLY
Andreas [ArjanCodes Team]

Looks good! However, some improvements can be made!

* First and foremost, what Python version are you using? In ex1, you are using Camera(str, Enum):, but for ex2 class EnemyType(StrEnum):. I would recommend to not use the mixin version, and instead use the StrEnum class.
* The create_factory function works as intended for the factory part, but I recommend moving out the skills selection outside the factory creation because they do not affect each other's creation or logic. Meaning, that those can be separated into two separate functions.

Other then that, this looks good! Nice that you also added exception handling to the solution! :D

REPLY
Scott Blake

Would these be valid alternatives? I approached things differently than the solution and I'm not sure these fulfill the objectives in the same way.

Ex 2a:
from abc import ABC
from dataclasses import dataclass
from enum import StrEnum
from random import choice, randint

class EnemyType(StrEnum):
KNIGHT = "knight"
ARCHER = "archer"
WIZARD = "wizard"

@dataclass
class Enemy:
enemy_type: EnemyType
health: int
attack_power: int
defense: int

@dataclass
class SpawnPoint(ABC):
stat_range_low: int = 0
stat_range_high: int = 100

@property
def spawnable_enemies(self) -> list[EnemyType]:
return []

def _random_stat(self) -> int:
return randint(self.stat_range_low, self.stat_range_high)

def spawn_enemy(self) -> Enemy:
return Enemy(
enemy_type=choice(self.spawnable_enemies),
health=self._random_stat(),
attack_power=self._random_stat(),
defense=self._random_stat(),
)

@dataclass
class EasySpawnPoint(SpawnPoint):
stat_range_low: int = 0
stat_range_high: int = 35

@property
def spawnable_enemies(self) -> list[EnemyType]:
return [EnemyType.KNIGHT, EnemyType.ARCHER]

@dataclass
class MediumSpawnPoint(SpawnPoint):
stat_range_low: int = 36
stat_range_high: int = 70

@property
def spawnable_enemies(self) -> list[EnemyType]:
return [EnemyType.KNIGHT, EnemyType.ARCHER, EnemyType.WIZARD]

@dataclass
class HardSpawnPoint(SpawnPoint):
stat_range_low: int = 71
stat_range_high: int = 100

@property
def spawnable_enemies(self) -> list[EnemyType]:
return [EnemyType.WIZARD]

def main() -> None:
print("Creating easy enemies.")
for _ in range(4):
print(EasySpawnPoint().spawn_enemy())

print("Creating medium enemies.")
for _ in range(3):
print(MediumSpawnPoint().spawn_enemy())

print("Creating hard enemies.")
for _ in range(2):
print(HardSpawnPoint().spawn_enemy())

if __name__ == "__main__":
main()

Ex 2b:
from abc import ABC
from dataclasses import dataclass
from enum import StrEnum
from random import choice, randint
from typing import Callable

class EnemyType(StrEnum):
KNIGHT = "knight"
ARCHER = "archer"
WIZARD = "wizard"

@dataclass
class Enemy:
enemy_type: EnemyType
health: int
attack_power: int
defense: int

SpawnFn = Callable[[], Enemy]

@dataclass
class SpawnPoint(ABC):
stat_range_low: int = 0
stat_range_high: int = 100

@property
def spawnable_enemies(self) -> list[EnemyType]:
return []

def _random_stat(self) -> int:
return randint(self.stat_range_low, self.stat_range_high)

def spawn_enemy(self) -> Enemy:
return Enemy(
enemy_type=choice(self.spawnable_enemies),
health=self._random_stat(),
attack_power=self._random_stat(),
defense=self._random_stat(),
)

@dataclass
class EasySpawnPoint(SpawnPoint):
stat_range_low: int = 0
stat_range_high: int = 35

@property
def spawnable_enemies(self) -> list[EnemyType]:
return [EnemyType.KNIGHT, EnemyType.ARCHER]

@dataclass
class MediumSpawnPoint(SpawnPoint):
stat_range_low: int = 36
stat_range_high: int = 70

@property
def spawnable_enemies(self) -> list[EnemyType]:
return [EnemyType.KNIGHT, EnemyType.ARCHER, EnemyType.WIZARD]

@dataclass
class HardSpawnPoint(SpawnPoint):
stat_range_low: int = 71
stat_range_high: int = 100

@property
def spawnable_enemies(self) -> list[EnemyType]:
return [EnemyType.WIZARD]

def main() -> None:
spawn_functions: dict[str, SpawnFn] = {
"easy": EasySpawnPoint().spawn_enemy,
"medium": MediumSpawnPoint().spawn_enemy,
"hard": HardSpawnPoint().spawn_enemy,
}

print("Creating easy enemies.")
for _ in range(4):
print(spawn_functions["easy"]())

print("Creating medium enemies.")
for _ in range(3):
print(spawn_functions["medium"]())

print("Creating hard enemies.")
for _ in range(2):
print(spawn_functions["hard"]())

if __name__ == "__main__":
main()

REPLY
Andreas [ArjanCodes Team]

This is a good start! But, some remarks can be made. Let's start with 2a:

* For the EnemyType enum, you could instead use StrEnum and the function auto(). This brings some extra nice functionality of annotations.
* The design, that I see, is quite reliant on implementing methods and attributes. However, there is no indication to help the developer if the needed implementations are done or not. This design is heavily reliant on having default values instead of generic behavior. For example, spawnable_enemies should not be implemented. Instead, it should be an @abstract_method that is required to be implemented by the subclasses of the ABC.
* The method spawn_enemy and _random_stat is heavily reliant on the attributes stored in the class. If they are not overridden by the subclasses, you will have unexpected behavior. For example, if EasySpawnPoint does not define a stat_range_high, the linter will not say anything. Because that value is defined in SpawnPoint. I would recommend keeping the ABC as minimal as possible and only implementing generic functionality.

The design itself is a good starting point! But, look over those bullet points and you will most likely see some improvements in the code. They do fulfill the objective, but the implementation is a bit too reliant on default values. It is better to let it crash instead of having default values for ABCs.

For 2b:

The main function is more or less completed. But, the foundation of the different types of spawning is not functional. Because it still relies on ABCs and inheritance which are object-oriented patterns. Instead, try changing each dataclass into a function and then use typing to set constraints. If you get stuck, I would recommend you to look at the example solution.

REPLY
Luca Baldini

This is my solution for ex2.

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import StrEnum
import random
from typing import Protocol

MIN_IDX_RANGE = 0
MAX_IDX_RANGE = 1

Range = tuple[int, int]

class EnemyType(StrEnum):
KNIGHT = "knight"
ARCHER = "archer"
WIZARD = "wizard"

@dataclass
class Enemy:
enemy_type: EnemyType
health: int
attack_power: int
defense: int

def make_enemy(ranges: EnemyRanges) -> Enemy:
enemy_types = ranges.spawnable_enemies()
enemy_type = enemy_types[random.randint(0, len(enemy_types) - 1)]
return Enemy(
enemy_type=enemy_type,
health=random.randint(
ranges.health_range()[MIN_IDX_RANGE],
ranges.health_range()[MAX_IDX_RANGE],
),
attack_power=random.randint(
ranges.attack_range()[MIN_IDX_RANGE],
ranges.attack_range()[MAX_IDX_RANGE],
),
defense=random.randint(
ranges.defense_range()[MIN_IDX_RANGE],
ranges.defense_range()[MAX_IDX_RANGE],
),
)

class EnemyRanges(ABC):
@abstractmethod
def health_range(self) -> Range: ...
@abstractmethod
def attack_range(self) -> Range: ...
@abstractmethod
def defense_range(self) -> Range: ...
@abstractmethod
def spawnable_enemies(self) -> tuple[EnemyType, ...]: ...

class EasyRanges(EnemyRanges):
def health_range(self) -> tuple[int, int]:
return (1, 5)

def attack_range(self) -> tuple[int, int]:
return (1, 5)

def defense_range(self) -> tuple[int, int]:
return (1, 5)

def spawnable_enemies(self) -> tuple[EnemyType, ...]:
return (EnemyType.KNIGHT, EnemyType.ARCHER)

class MediumRanges(EnemyRanges):
def health_range(self) -> tuple[int, int]:
return (5, 10)

def attack_range(self) -> tuple[int, int]:
return (5, 10)

def defense_range(self) -> tuple[int, int]:
return (5, 10)

def spawnable_enemies(self) -> tuple[EnemyType, ...]:
return (EnemyType.KNIGHT, EnemyType.ARCHER, EnemyType.WIZARD)

class HardRanges(EnemyRanges):
def health_range(self) -> tuple[int, int]:
return (10, 30)

def attack_range(self) -> tuple[int, int]:
return (10, 30)

def defense_range(self) -> tuple[int, int]:
return (10, 30)

def spawnable_enemies(self) -> tuple[EnemyType, ...]:
return (EnemyType.WIZARD,)

FACTORIES = {
"easy": EasyRanges(),
"medium": MediumRanges(),
"hard": HardRanges(),
}

def enemy_ranges_factory(spawn_type: str) -> EnemyRanges:
return FACTORIES[spawn_type]

def spawn_enemy(spawn_type: str) -> Enemy:
print(f"Spawning enemy with {spawn_type}")
enemy_ranges = enemy_ranges_factory(spawn_type=spawn_type)
return make_enemy(ranges=enemy_ranges)

def main() -> None:
for en_t in FACTORIES:
enemy = spawn_enemy(en_t)
print(enemy)

if __name__ == "__main__":
main()

REPLY
Andreas [ArjanCodes Team]

It is a good start. But, there are some changes that needs to be made in order to make the exercise complete.

First, the different ranges classes (EasyRanges, MediumRanges , and HardRanges ) can be replaced by dataclasses for each, with an protocol that sets the requirement that attack, defense, and health is needed.

Furthermore, there are some coupling happening between the functions. For example spawn_enemy and make_enemy relies either on global constants or other function definitions. Try breaking these up, and patch everything together in the main function.

Start with these paragraph and look at the solution to get some inspiration of how this can be improved! :)

REPLY
Luca Baldini

Is this a valid alternative to solution of ex1? I tried to follow also the principle of minimum responsibility by letting `Browser` to open only URLs.

from dataclasses import dataclass
from enum import StrEnum

class Camera(StrEnum):
FRONT = "front"
BACK = "back"

def qr_code_to_url() -> str:
qr = QRScanner()
qr.choose_camera(Camera.BACK)
return qr.scan()

@dataclass
class QRScanner:
camera: Camera = Camera.FRONT

def choose_camera(self, camera: Camera) -> None:
print(f"Choosing camera {camera.value}.")
self.camera = camera

def scan(self) -> str:
print(f"Scanning QR code with {self.camera.value} camera.")
return "https://www.arjancodes.com"

class Browser:
def open(self, url: str) -> None:
print(f"Opening {url} in the browser.")

def main() -> None:
print("Navigating to website on device.")
browser = Browser()
browser.open(qr_code_to_url())

REPLY
Andreas [ArjanCodes Team]

Nice that you are applying multiple principles! However, this solution requires a bit more work in terms of separating creation from use. At the moment qr_code_to_url both creates and use the QR instance. I suggest that you move the initializing outside of the function and pass it through arguments.

REPLY
Show More